This repository has no description
1import type { Did } from "@atcute/lexicons/syntax";
2import { createBobbinClient } from "$lib/api/client";
3import { fetchPage, items } from "$lib/api/pagination";
4import { count } from "$lib/api/count";
5import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records";
6import { IdentityCache } from "$lib/api/identity";
7import { didFromUri, rkeyFromUri } from "$lib/api/uri";
8import { toHttpError, parallel } from "$lib/api/load";
9import { search } from "$lib/api/search";
10import type { BobbinContext } from "$lib/api/client";
11import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph";
12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
15import type {
16 RepoCardData,
17 StringCardData,
18 PersonData,
19 VouchData,
20 StarData
21} from "$lib/components/profile/types";
22import type { PageLoad } from "./$types";
23
24const PAGE_LIMIT = 50;
25
26const TABS = [
27 "overview",
28 "repos",
29 "starred",
30 "strings",
31 "followers",
32 "following",
33 "vouches"
34] as const;
35type Tab = (typeof TABS)[number];
36
37const normalizeTab = (raw: string | null): Tab =>
38 TABS.includes(raw as Tab) ? (raw as Tab) : "overview";
39
40interface ListItem {
41 uri: string;
42 value: unknown;
43}
44
45const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
46 const value = item.value as RepoRecord;
47 return {
48 rkey: rkeyFromUri(item.uri),
49 name: value.name ?? rkeyFromUri(item.uri),
50 repoDid: value.repoDid ?? "",
51 ownerHandle,
52 description: value.description,
53 knot: value.knot,
54 createdAt: value.createdAt
55 };
56};
57
58interface ResolveRepoCardOptions {
59 viewerStarRkeys?: ReadonlyMap<string, string>;
60}
61
62const resolveRepoCard = async (
63 ctx: BobbinContext,
64 item: ListItem,
65 ownerHandle: string,
66 options: ResolveRepoCardOptions = {}
67): Promise<RepoCardData> => {
68 const repo = toRepoCard(item, ownerHandle);
69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
70 // TODO(bobbin): instead of doing this, listing repos should return star counts
71 // and most likely other stats as well.
72 const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid);
73 return {
74 ...repo,
75 stars: stars.count,
76 viewerStarRkey: options.viewerStarRkeys
77 ? (options.viewerStarRkeys.get(repo.repoDid) ?? null)
78 : undefined
79 };
80};
81
82const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
83 const value = item.value as ShTangledString.Main;
84 return {
85 rkey: rkeyFromUri(item.uri),
86 ownerHandle,
87 filename: value.filename,
88 description: value.description,
89 createdAt: value.createdAt,
90 lines: value.contents?.split("\n").length ?? 1
91 };
92};
93
94// resolve dids -> handle/avatar, deduped, preserving input order.
95const resolvePeople = async (
96 ctx: BobbinContext,
97 dids: string[],
98 viewerDid?: string
99): Promise<PersonData[]> => {
100 const cache = new IdentityCache(ctx);
101 const unique = [...new Set(dids)];
102
103 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null)));
104 // TODO(bobbin): need bobbin to return follower / following stats when listing follows..
105 const counts = await parallel(
106 unique.reduce(
107 (acc, did) => {
108 acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did)
109 .then((result) => result.count)
110 .catch(() => 0);
111 acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did)
112 .then((result) => result.count)
113 .catch(() => 0);
114 return acc;
115 },
116 {} as Record<string, Promise<number>>
117 )
118 );
119
120 const viewerFollowRkeys = new Map<string, string>();
121 if (viewerDid) {
122 for await (const item of items(
123 ctx,
124 "sh.tangled.graph.listFollowsBy",
125 { subject: viewerDid as Did },
126 { maxPages: 10 }
127 )) {
128 const value = item.value as FollowRecord;
129 viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri));
130 }
131 }
132
133 const byDid = new Map<string, PersonData>();
134 unique.forEach((did, index) => {
135 const doc = docs[index];
136 const followers = counts[`${did}-followers`];
137 const following = counts[`${did}-following`];
138 const isSelf = viewerDid === did;
139 const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined;
140 byDid.set(
141 did,
142 doc
143 ? {
144 did: doc.did,
145 handle: doc.handle,
146 avatar: doc.avatar,
147 followers,
148 following,
149 isSelf,
150 viewerFollowRkey
151 }
152 : { did, handle: did, followers, following, isSelf, viewerFollowRkey }
153 );
154 });
155 return unique.map((did) => byDid.get(did) as PersonData);
156};
157
158const resolveVouches = async (ctx: BobbinContext, items: ListItem[]): Promise<VouchData[]> => {
159 const cache = new IdentityCache(ctx);
160 return Promise.all(
161 items.map(async (item): Promise<VouchData> => {
162 const value = item.value as VouchRecord;
163 const voucher = didFromUri(item.uri);
164 const doc = await cache.resolve(voucher).catch(() => null);
165 return {
166 uri: item.uri,
167 did: voucher,
168 handle: doc?.handle ?? voucher,
169 avatar: doc?.avatar,
170 kind: value.kind === "denounce" ? "denounce" : "vouch",
171 reason: value.reason,
172 createdAt: value.createdAt
173 };
174 })
175 );
176};
177
178const resolveStars = async (
179 ctx: BobbinContext,
180 items: ListItem[],
181 options: ResolveRepoCardOptions
182): Promise<StarData[]> => {
183 const cache = new IdentityCache(ctx);
184 const resolved = await Promise.all(
185 items.map(async (item): Promise<StarData | null> => {
186 const value = item.value as ShTangledFeedStar.Main;
187 const subject = value.subject;
188 if (subject && "did" in subject && subject.did) {
189 try {
190 const repo = await getRepoByRepoDid(ctx, subject.did);
191 const ownerDid = didFromUri(repo.uri);
192 const owner = await cache.resolve(ownerDid).catch(() => null);
193 return {
194 kind: "repo",
195 uri: item.uri,
196 createdAt: value.createdAt,
197 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options)
198 };
199 } catch {
200 return null;
201 }
202 }
203 if (subject && "uri" in subject && subject.uri) {
204 const ownerDid = didFromUri(subject.uri);
205 const owner = await cache.resolve(ownerDid).catch(() => null);
206 return {
207 kind: "string",
208 uri: item.uri,
209 createdAt: value.createdAt,
210 ownerHandle: owner?.handle ?? ownerDid,
211 rkey: rkeyFromUri(subject.uri)
212 };
213 }
214 return null;
215 })
216 );
217 return resolved.filter((star): star is StarData => star !== null);
218};
219
220export const load: PageLoad = async (event) => {
221 const parent = await event.parent();
222 const tab = normalizeTab(event.url.searchParams.get("tab"));
223
224 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } };
225
226 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
227 const did = parent.identity.did as Did;
228 const handle = parent.identity.handle;
229
230 try {
231 switch (tab) {
232 case "repos": {
233 const q = event.url.searchParams.get("q")?.trim();
234 const [found, viewerStarRkeys] = await Promise.all([
235 q
236 ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then(
237 (page) => page.hits
238 )
239 : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then(
240 (page) => page.items
241 ),
242 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
243 ]);
244 return {
245 tab,
246 repos: await Promise.all(
247 found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys }))
248 )
249 };
250 }
251 case "strings": {
252 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
253 subject: did,
254 limit: PAGE_LIMIT
255 });
256 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) };
257 }
258 case "followers": {
259 const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", {
260 subject: did,
261 limit: PAGE_LIMIT
262 });
263 const dids = page.items.map((item) => didFromUri(item.uri));
264 return {
265 tab,
266 people: await resolvePeople(ctx, dids, parent.auth?.did)
267 };
268 }
269 case "following": {
270 const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", {
271 subject: did,
272 limit: PAGE_LIMIT
273 });
274 const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject);
275 return {
276 tab,
277 people: await resolvePeople(ctx, dids, parent.auth?.did)
278 };
279 }
280 case "vouches": {
281 const page = await fetchPage(ctx, "sh.tangled.graph.listVouches", {
282 subject: did,
283 limit: PAGE_LIMIT
284 });
285 return { tab, vouches: await resolveVouches(ctx, page.items) };
286 }
287 case "starred": {
288 const [page, viewerStarRkeys] = await Promise.all([
289 fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }),
290 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
291 ]);
292 return {
293 tab,
294 stars: await resolveStars(ctx, page.items, { viewerStarRkeys })
295 };
296 }
297 case "overview":
298 default: {
299 const [page, viewerStarRkeys] = await Promise.all([
300 fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }),
301 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined
302 ]);
303
304 const pinnedKeys = parent.profile?.pinnedRepositories ?? [];
305 const byKey = new Map<string, ListItem>();
306 for (const item of page.items) {
307 const value = item.value as RepoRecord;
308 if (value.repoDid) byKey.set(value.repoDid, item);
309 byKey.set(item.uri, item);
310 }
311 const pinnedItems = pinnedKeys
312 .map((key) => byKey.get(key))
313 .filter((item): item is ListItem => item !== undefined);
314 const pinned = await Promise.all(
315 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys }))
316 );
317
318 return { tab: "overview" as const, overview: { pinned } };
319 }
320 }
321 } catch (cause) {
322 console.error("Page load error:", cause);
323 toHttpError(cause, "Could not load profile data");
324 }
325};